Published on

토큰 기반 인증 및 자동 갱신 구현 하기

Authors
  • avatar
    Name
    Jiny
    Twitter

Intro

투룻의 경우, 토큰 기반 인증 & 인가 방식을 채택하여 서비스를 구성했다.

해당 방식을 통해 우리가 해결하고자 했던 문제는 아래와 같다.

  1. 인증 된 사용자만 여행 계획 생성 및 조회 가능
  2. 일정 시간이 지났을 때 로그인 갱신

왜 토큰 기반 인증 & 인가 방식을 채택했는지, 위 문제 들을 어떻게 해결했는지에 대해 상세히 기술하고자 이 글을 작성하게 되었다.

인증 & 인가

토큰 기반 인증 & 인가 방식을 채택한 이유를 살펴보기 전 인증과 인가에 대해 먼저 살펴볼 필요가 있다.

인증

서비스에 등록된 유저의 신원을 입증하는 과정

인증의 대표적인 예시는 회원가입 & 로그인이다. 투룻의 경우 카카오 로그인을 하는 것 자체가 인증 과정이라고 볼 수 있다.

인가

인증된 사용자가 어떠한 자원에 접근할 수 있는지 확인하는 절차

자원이라고 한다면, 서비스에서 부여 받은 권한과 비슷하다고 생각할 수 있다.

투룻에서 제공하는 권한은 아래와 같다.

  1. 마이페이지 접근
  2. 여행 계획 상세 페이지 접근
  3. 여행기 썸네일 & 이미지 업로드
  4. 여행 계획 & 여행기 등록

토큰 기반 인증 & 인가 방식

사용자의 인증 정보를 클라이언트에서 저장하는 방식

순서

  1. 유저가 서비스 내에서 로그인을 한다.
  2. 로그인 요청을 받으면, 서버에서 유저 정보를 확인 후 일치할 경우 토큰을 발급한다.
  3. 클라이언트에게 토큰을 반환한다.
  4. 클라이언트는 받은 토큰들을 브라우저에 저장한다.
  5. 저장한 토큰을 기반으로 서버에 api 요청을 한다.
  6. 서버는 사용자 인증 후 특정 요청에 대한 response를 내려준다.

토큰 기반 인증 & 인가 방식을 택한 이유

토큰 기반 방식 이외 2가지 선택지(basic 인증, 세션)가 있었지만 아래와 같은 이유로 토큰 기반 방식을 채택했다.

  1. basic 인증 방식의 경우 매 요청 마다 base64로 인코딩 된 사용자 정보(사용자 이름, 비밀번호)가 쉽게 디코딩 될 수 있다.
  2. 세션 기반 방식의 경우 많은 사용자의 세션을 관리하므로 서버 리소스 사용량이 증가할 수 있다.

토큰 기반 방식의 경우, secret key를 서버에 위치시키면 서버가 증가하더라도 동일하게 인증이 가능하며, 사용자 정보를 저장하지 않는 stateless한 방식이라 서버 부하를 줄일 수 있다.

또한, 토큰 만료 기간을 짧게 설정한다면 디코딩 되는 문제도 어느 정도 해결이 가능하다.

위와 같은 이유로 토큰 기반 인증 & 인가 방식을 택하게 되었다.

크게 token은 access token과 refresh token으로 나눌 수 있다.

  • access token
    • 사용자 인증을 위한 토큰
  • refresh token
    • access token 만료 시 재발급 요청을 위한 토큰

즉, access token은 서비스에 접근하기 위한 토큰이며, refresh token의 경우 새로운 access token을 발급받기 위한 토큰으로 각각의 사용 목적이 다르다.

Axios Interceptor를 활용한 인증 & 인가 구현

우리팀의 경우 Axios Interceptor를 활용해서 해당 기능을 구현하게 되었다.

그렇다면 Axios Interceptor가 무엇일까?

Axios Interceptor

http request ~ response 요청을 가로채어 특정 기능을 핸들링 할 수 있는 기능

즉, 2가지 case에 대한 요청을 가로챌 수 있다.

  1. api request 전(axios.interceptors.request)
  2. api response 이후(axios.interceptors.response)
// example

// api request 전
authClient.interceptors.request.use(handlePreviousRequest, handleAPIError)
// api response 후
authClient.interceptors.response.use((response) => response, handleAPIError)

다음과 같이 axios 인스턴스에 대해 interceptors.request(response).use를 통해 2가지 case들에 대한 요청을 가로챌 수 있다.

api request 전

api request 전 handlePreviousRequest라는 핸들러 함수를 실행하는 것을 알 수 있다.

// interceptor.ts
export const handlePreviousRequest = (config: InternalAxiosRequestConfig) => {
  const user: UserResponse | null = JSON.parse(localStorage.getItem(STORAGE_KEYS_MAP.user) ?? '{}')
  let newConfig = { ...config }

  newConfig = checkAccessToken(config, user?.accessToken ?? null)
  newConfig = setAuthorizationHeader(config, user?.accessToken ?? '')

  return newConfig
}

handlePreviousRequest는 크게 2가지 역할을 수행한다.

  1. request header 내 Authorization에 access token 추가

그렇다면 왜 Authorization 속성에 access token을 추가해야할까?

위와 같이 authorization header 내 access token 없이 요청을 날리게 되면 서버 측에서 인가되지 않은 사용자라고 인식하여 다음과 같이 401 Unauthorized error를 발생시킨다.

export const setAuthorizationHeader = (
  config: InternalAxiosRequestConfig,
  accessToken: string | null
) => {
  if (!config.headers || config.headers.Authorization || !accessToken) return config

  config.headers.Authorization = `Bearer ${accessToken}`

  return config
}

즉, 사용자의 권한 여부를 확인하기 위한 수단으로써 access token을 활용하는 것을 알 수 있다.

  1. access token이 있는지 검증
export const checkAccessToken = (
  config: InternalAxiosRequestConfig,
  accessToken: string | null
) => {
  if (!accessToken) {
    alert(ERROR_MESSAGE_MAP.api.login)
    window.location.href = ROUTE_PATHS_MAP.login
  }

  return config
}

유저의 access token이 없다면 login 페이지로 리다이렉팅을 하게 된다.

한번 access token을 제거해서 확인해보자.

after

로그인 alert가 등장 후 로그인 페이지로 리다이렉팅 되는 것을 알 수 있다.

api response 후

해당 phase의 대표적인 task로 토큰 만료 처리가 있다.

우리 팀의 경우 토큰 탈취의 위험성을 최대한 방지하기 위해 access token 만료 기간은 30분으로 잡아둔 상태이다.

이 경우, 30분 마다 토큰 만료로 인해 다시 로그인 페이지로 리다이렉팅되어 로그인을 해야하는 상황이 발생하게되어 사용자 경험이 좋지 않게 된다.

이를 위해 위와 같이 refresh token을 활용하여 사용자 경험을 높여줄 수 있다.

서버에서 401 토큰 만료 에러를 발생시키면, access token 재요청을 보내게 된다.

이 때, refresh token을 Authorization 헤더에 담아 요청을 보내게 되는데, 서버는 access token을 확인하듯 refresh token을 확인하여 만료되었다면 다시 유저 정보를, 그렇지 않다면 에러를 내려주어 클라이언트에서 처리된다.

코드를 통해선 아래와 같이 나타낼 수 있다.

export const handleAPIError = async (error: AxiosError<ErrorResponse>) => {
  // 1. status code가 401이고, 토큰 만료 메시지를 받았다면
  if (
    error.response?.status === HTTP_STATUS_CODE_MAP.UNAUTHORIZED &&
    error.response.data.message === ERROR_MESSAGE_MAP.api.expiredToken
  ) {
    try {
      // 2. storage에서 user 데이터를 가져와서
      const user: UserResponse | null = JSON.parse(
        localStorage.getItem(STORAGE_KEYS_MAP.user) ?? '{}'
      )

      // 3. axios instance의 baseUrl를 BASE_URL로 설정하고
      axios.defaults.baseURL = process.env.REACT_APP_BASE_URL

      // 4. 스토리지에 있는 refresh token을 받아와서 서버 측에 토큰 재발행 요청
      const response: AxiosResponse<AuthTokenResponse> = await axios.post(
        API_ENDPOINT_MAP.reissueToken,
        { refreshToken: user?.refreshToken }
      )

      // 5. 받아온 요청을 기반으로
      const { accessToken, refreshToken, memberId } = response.data

      // 6. 다시 Storage에 저장
      localStorage.setItem(
        STORAGE_KEYS_MAP.user,
        JSON.stringify({ memberId, accessToken, refreshToken })
      )

      if (error.config && error.config.headers) {
        // 7. 다시 refresh된 token을 기반으로 다시 재요청
        error.config.headers.Authorization = `Bearer ${accessToken}`
        return axios.request(error.config)
      }
    } catch (refreshTokenError) {
      // 8. 만약 refresh token도 만료되었다면 재로그인
      handleUserLogout()
    }
  }
}

refresh token이 만료된 경우

refresh token이 만료되지 않은 경우

refresh token까지 만료되어버리면 위와 같이 두번 에러가 발생하는 것을 확인할 수 있는 반면, refresh token이 만료되지 않았다면 reissue token 요청을 통해 토큰을 받아와 다시 요청을 보내는 것을 알 수 있다.

이를 통해 유저가 다시 로그인을 하지 않아도 되기 때문에 사용자 경험이 개선 되는 것을 알 수 있다.